Live file-watching for external edits (desktop)#13
Merged
Conversation
One debounced notify/FSEvents watcher per open folder in the Rust shell
(notes_watch/notes_unwatch, per-window-label refcounted subscriptions so
StrictMode's watch,watch,unwatch order stays subscribed), emitting
notes:changed {dir, paths} to each subscriber. The path filter passes .md
leaves, existing dirs (a Finder folder rename reports only dir paths), and
vanished paths; drops dot-entries, node_modules, root Attachments/, and
write temps; >64 paths or a watcher error collapses to an empty list
("refresh everything").
The seam grows an optional NoteStore.watch (desktop-only, like reveal);
useNotes subscribes once ready: conflict-check THEN refresh (reconcile
nulls a vanished active, so the other order would swallow the
deleted-conflict banner), 300 ms trailing debounce + in-flight coalescing,
self-echo suppression via a 3 s local-write stamp map (incl. ancestor
dirs; empty paths never suppressible), a saveInFlight gate against
phantom conflicts mid-save, and a hidden-latch that replays on
visibilitychange. Web backends keep the focus-driven refresh and gain a
"Reload notes" orb-menu item.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README keeps features, a two-path Getting started (download the release / run from source, with requirements folded in), doc pointers, and the backlog. The architecture section, on-disk format, and known limitations move to docs/architecture.md; the shortcut sheet moves to docs/shortcuts.md, regenerated from the SHORTCUTS descriptor (it had drifted — ctrl+R, cmd+shift+I, cmd+D, cmd+, were missing). CLAUDE.md gains a Docs section with keep-in-sync rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Findings from an xhigh /code-review round plus the external REVIEW.md, all verified against the code before fixing: Correctness - checkOpenNoteConflict re-validates after its stat await (same note still open, no conflict/pending/save, baseline unchanged): a note switch mid-stat raised a phantom conflict for the WRONG note, whose banner actions key off conflict.id — "Keep mine" could save the open note's body over the statted one's file. - refresh() keeps a vanished `active` alive while its deleted-conflict banner is up (conflictRef is now mirrored synchronously at every raise site so the guard can see it): reconcile nulled the pointer, after which edit() silently dropped every keystroke typed under the banner and "Save as copy" persisted only pre-deletion content. - "Reload notes" (and the post-import refresh) go through a new reload() = conflict-check-then-refresh; the focus re-list now uses the same order. A bare refresh() swallowed the deleted-conflict banner on the web backends. - Echo suppression defers instead of drops: an echo-only batch schedules ONE trailing verify run past the stamp window, so a false-positive (external dir-only event under an ancestor stamp, external rewrite of a just-saved note) delays a refresh instead of losing it — suppression is now genuinely a perf-only guard. - Rust: an event on the watch root (FSEvents' queue-overflow rescan, all the debouncer leaves of it) now emits EMPTY paths = "refresh everything" instead of being filtered to nothing. - Rust: watch subscriptions are drained on page load (a WKWebView crash- reload runs no JS cleanup and fires no Destroyed — each one leaked the label's refcount and pinned dead FSEvents watchers alive) and re-checked for window liveness after each notes_watch (the unlocked build gap could land a subscription for an already-destroyed window that nothing would ever drain). Both paths share remove_window_subscriptions(), which returns emptied entries for the caller to drop outside the mutex. - Rust: dot-named `.md` notes pass the watch filter (the note walks list `.hidden.md` — only dot DIRS are skipped — so its events must flow too). - refresh() merges newest-wins per row and addNote replaces-not-appends: a refresh whose list snapshot predates an in-flight save clobbered the fresh preview, and a concurrent refresh during create() doubled the new row. - TauriNoteStore.watch's disposer is idempotent per handle (a double-dispose double-decremented the window's refcount). Perf / cleanup - Import runs under withBulkWrites(), holding the watcher/focus pipelines off (bulk writes bypass the echo stamps; every batch triggered a full mid-import re-list). - createFolder stamps the store-returned (sanitized) path. - Shared WRITE_TMP_SUFFIX / RENAME_TMP_SUFFIX consts + mirror comments on the TS minting sites; cross-referenced the two debounce constants (REVIEW.md #1); watch() failure now leaves a console.warn; "Reload notes" uses ArrowsRotateRight (REVIEW.md #4); dead test field removed. Docs - architecture.md: the metadata last-write-wins limitation applies to desktop windows too, not just browser tabs (the shell coordinates window assignment and note bodies, not the sidecar). - Restored facts lost in the README slimming: system theme, search stays global under a folder scope, plain click opens links in preview. - CLAUDE.md updated to the new invariants; REVIEW.md consumed and removed. Verified: cargo test (25) + clippy clean, vitest 922 (2 new regression tests: keystrokes survive under a deleted-conflict banner; reload() raises the banner before refresh), typecheck, lint, prettier, production build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The desktop app now picks up external changes to the notes folder live — another app, another window, a sync agent, a shell script — without waiting for the window to regain focus. Edits show up in the list within ~1 s, and an external change to the open note raises the conflict banner immediately. The web builds keep the focus-driven refresh and gain a manual Reload notes item in the orb menu.
Also rides along: a docs restructure (README slimmed to a feature list + two-path Getting started; new
docs/architecture.mdwith the on-disk format and known limitations, anddocs/shortcuts.mdwith the full shortcut sheet).How
src-tauri/src/lib.rs): one debouncednotify/FSEvents watcher per open folder (notes_watch/notes_unwatch+ aWatchersstate), shared by every window on that folder via per-window-label refcounted subscriptions (StrictMode makeswatch, watch, unwatcha legal wire order). Emitsnotes:changed {dir, paths}per subscriber;dirstays the raw frontend path (strict-equalsTauriNoteStore.dir) while path filtering runs against the canonicalized root (FSEvents resolves/var→/private/var). The filter passes.mdleaves, existing dirs (a Finder folder rename reports only dir paths), and vanished paths; noise (dot dirs, sidecar,Attachments/, write temps) is dropped at the source. >64 paths, a watcher error, or a watch-root event (FSEvents' overflow rescan) → emptypaths= "refresh everything".NoteStore.watch?(onChange)(likereveal?), implemented only byTauriNoteStore— listen-before-invoke so no event slips the gap, sync idempotent disposer.useNotes: aready-gated watch effect — 300 ms trailing debounce, in-flight coalescing, conflict-check-then-refresh (reconcile nulls a vanishedactive, so the other order would swallow the deleted-conflict banner), hidden-latch with replay on visibility, and self-echo suppression (own writes stamp a 3 s map; echo-only batches defer one trailing verify run instead of refreshing per autosave).Review hardening
The branch had two review rounds (an xhigh multi-agent
/code-review+ an independent review); everything verified landed in the last commit:checkOpenNoteConflictre-validates after its stat await — a note switch mid-stat could raise a phantom conflict for the wrong note, and "Keep mine" would then save the open note's body over the statted one's file (cross-note corruption; pre-existing on the focus path, made hittable by the watcher).refresh()keeps a deleted note'sactivealive while its banner is up — otherwise every keystroke typed under the banner was silently dropped and "Save as copy" persisted only pre-deletion content.reload()(check-then-refresh); a barerefresh()swallowed the deleted-conflict on the web backends.on_page_load; anotes_watchracing window destruction reaps itself; the drop-outside-mutex rule (aWatcherEntry's Drop joins the debouncer thread) is structural via a shared teardown helper..mdnotes pass the filter (they're listed, so they must refresh); newest-wins list merge +addNotededup (two refresh-vs-mutation races); import pauses the pipelines (withBulkWrites); idempotent disposer; shared temp-suffix consts; docs accuracy fixes.Performance
Idle cost is ~zero (FSEvents is kernel-push; one sleeping thread per folder, count-independent of vault size). Reaction cost equals the existing focus-refresh, bounded by 400 ms native + 300 ms JS debounce + coalescing; the app's own saves never trigger it. Verified against a 796-note stress vault.
Tests
cargo test25 ✓ (filter cases, refcount interleavings) + clippy clean; vitest 922 ✓ incl. new suites for the watch pipeline (external create/conflict/echo-defer/empty-paths/hidden-latch/late-dispose, keystrokes-survive-deleted-banner, reload ordering) andTauriNoteStore.watch(listen-before-invoke, dir filter, disposer, failure path); typecheck / lint / prettier / production build all green. Manual E2E over the demo + stress vaults (create/append/mkdir/mv, noise, floods, two-window, hidden-latch, workspace switches).🤖 Generated with Claude Code